gh-138577: Fix keyboard shortcuts in getpass with echo_char#141597
gh-138577: Fix keyboard shortcuts in getpass with echo_char#141597CuriousLearner wants to merge 3 commits intopython:mainfrom
Conversation
When using getpass.getpass(echo_char='*'), keyboard shortcuts like Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next) now work correctly by reading the terminal's control character settings and processing them in non-canonical mode.
picnixz
left a comment
There was a problem hiding this comment.
Thanks for doing this but this looks like a very complicate patch which is what I feared :( Also, I personally would use Ctrl+a/Ctrl+e for jumping at the start/end of what I write and ctrl+k (I think people are more familiar with ctrl+a/ctrl+k rather than ctrl+u for erasing an entire line in general...).
So I'm not really confident in actually changing this. I think deleting the last character that was typed (with DEL) is usually what people expect.
In addition, to make the feature more extendable in the future, I would prefer a tokenizer-based + dispatcher based approach instead (like we currently do for the REPL) because with many ifs like that it becomes hard to follow what's being done. But this would require an refactoring of this module which I don't really know if it's worth.
Lib/getpass.py
Outdated
| # Control chars from termios are bytes, convert to str | ||
| erase_char = term_ctrl_chars['ERASE'].decode('latin-1') if isinstance(term_ctrl_chars['ERASE'], bytes) else term_ctrl_chars['ERASE'] | ||
| kill_char = term_ctrl_chars['KILL'].decode('latin-1') if isinstance(term_ctrl_chars['KILL'], bytes) else term_ctrl_chars['KILL'] | ||
| werase_char = term_ctrl_chars['WERASE'].decode('latin-1') if isinstance(term_ctrl_chars['WERASE'], bytes) else term_ctrl_chars['WERASE'] | ||
| lnext_char = term_ctrl_chars['LNEXT'].decode('latin-1') if isinstance(term_ctrl_chars['LNEXT'], bytes) else term_ctrl_chars['LNEXT'] |
There was a problem hiding this comment.
Those are too long loines and they are unreadable. All of that can be a single function that is given the action to perform and the current capabilities.
Lib/getpass.py
Outdated
| erase_char = '\x7f' # DEL | ||
| kill_char = '\x15' # Ctrl+U | ||
| werase_char = '\x17' # Ctrl+W | ||
| lnext_char = '\x16' # Ctrl+V |
There was a problem hiding this comment.
Ideally, we should have them defined in a global private dict instead to ease maintenance.
Lib/getpass.py
Outdated
| term_ctrl_chars = { | ||
| 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else b'\x7f', | ||
| 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else b'\x15', | ||
| 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else b'\x17', | ||
| 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else b'\x16', | ||
| } |
There was a problem hiding this comment.
This code must be refactored with a combination of a global dict with the defaults and a function.
|
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated. Once you have made the requested changes, please leave a comment on this pull request containing the phrase |
Agree on this, since the original ticket asked for Ctrl+U, I would handle all these Ctrl characters.
I'm refactoring the patch to be more extensible in the future, especially as more control characters are added. |
|
It actually grew a lot bigger than I initially expected, but based on your review, I can iterate over this @picnixz |
|
It looks great. The patch itself is small but the tests are long so I think it is fine. Nonetheless I think it would be better to have it in 3.15 rather than in 3.14.1. WDYT? (i need to review it but not today) |
Lib/getpass.py
Outdated
| # Handle literal next mode FIRST (Ctrl+V quotes next char) | ||
| if editor.literal_next: | ||
| editor.handle_literal_next(char) | ||
| continue |
There was a problem hiding this comment.
To reduce the number of continue, just use if-elif constructions here.
Lib/getpass.py
Outdated
| handler = dispatch.get(char) | ||
| if handler: | ||
| handler() | ||
| else: | ||
| passwd += char | ||
| stream.write(echo_char) | ||
| stream.flush() | ||
| eof_pressed = False | ||
| return passwd | ||
| editor.insert_char(char) | ||
| editor.eof_pressed = False |
There was a problem hiding this comment.
Here's a suggestion: don't build a dispatch table via a function but make the editor handles it internally:
editor.handle(char)
Lib/getpass.py
Outdated
| for name, value in ctrl_chars.items()} | ||
|
|
||
| def refresh_display(self): | ||
| """Redraw the entire password line with asterisks.""" |
There was a problem hiding this comment.
| """Redraw the entire password line with asterisks.""" | |
| """Redraw the entire password line with *echo_char*.""" |
Lib/getpass.py
Outdated
| try: | ||
| old = termios.tcgetattr(fd) | ||
| cc = old[6] # Index 6 is the control characters array | ||
| return { | ||
| 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'], | ||
| 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'], | ||
| 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], | ||
| 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], | ||
| 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], | ||
| 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], | ||
| # Ctrl+A/E/K are not in termios, use POSIX defaults | ||
| 'SOH': _POSIX_CTRL_CHARS['SOH'], | ||
| 'ENQ': _POSIX_CTRL_CHARS['ENQ'], | ||
| 'VT': _POSIX_CTRL_CHARS['VT'], | ||
| } | ||
| except (termios.error, OSError): | ||
| return _POSIX_CTRL_CHARS.copy() |
There was a problem hiding this comment.
| try: | |
| old = termios.tcgetattr(fd) | |
| cc = old[6] # Index 6 is the control characters array | |
| return { | |
| 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'], | |
| 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'], | |
| 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], | |
| 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], | |
| 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], | |
| 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], | |
| # Ctrl+A/E/K are not in termios, use POSIX defaults | |
| 'SOH': _POSIX_CTRL_CHARS['SOH'], | |
| 'ENQ': _POSIX_CTRL_CHARS['ENQ'], | |
| 'VT': _POSIX_CTRL_CHARS['VT'], | |
| } | |
| except (termios.error, OSError): | |
| return _POSIX_CTRL_CHARS.copy() | |
| res = _POSIX_CTRL_CHARS.copy() | |
| try: | |
| old = termios.tcgetattr(fd) | |
| cc = old[6] # Index 6 is the control characters array | |
| except (termios.error, OSError): | |
| return res | |
| # Ctrl+A/E/K are not in termios, use POSIX defaults | |
| for name in ["ERASE", "KILL", "WERASE", "LNEXT", "EOF", "INTR"] | |
| cap = getattr(termios, f"V{name}") | |
| if cap < len(cc): | |
| res[name] = cc[flag] | |
| return res |
There was a problem hiding this comment.
Thanks for the suggestion, there was some minor issue here, which I've fixed in my refactoring
Lib/getpass.py
Outdated
|
|
||
| def refresh_display(self): | ||
| """Redraw the entire password line with asterisks.""" | ||
| self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r') |
There was a problem hiding this comment.
Why are we adding 20 extra characters?
There was a problem hiding this comment.
The +20 was an arbitrary buffer and has been removed. The current implementation now uses just len(self.passwd) to clear only the necessary characters:
self.stream.write('\r' + ' ' * len(self.passwd) + '\r')
Lib/getpass.py
Outdated
| self.ctrl = {name: _decode_ctrl_char(value) | ||
| for name, value in ctrl_chars.items()} |
There was a problem hiding this comment.
Can't we have the POSIX defaults already decoded?
Lib/getpass.py
Outdated
| 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], | ||
| 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], | ||
| 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], | ||
| 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], |
There was a problem hiding this comment.
I'm assuming cc[termios.VINTR] gives bytes right? in this case we can already decode it.
There was a problem hiding this comment.
Yes, cc[termios.VINTR] returns bytes. This is now decoded inline in the refactored implementation:
for name in ('ERASE', 'KILL', 'WERASE', 'LNEXT', 'EOF', 'INTR'):
cap = getattr(termios, f'V{name}')
if cap < len(cc):
res[name] = cc[cap].decode('latin-1')The _POSIX_CTRL_CHARS defaults are also now stored as already-decoded strings.
|
Hi @picnixz 👋🏼 Sorry, it took some time to address the review here. I've refactored the patch & I think |
Fixes #138577
When using
getpass.getpass(echo_char='*'), keyboard shortcuts likeCtrl+U(kill line),Ctrl+W(erase word), andCtrl+V(literal next) now work correctly by reading the terminal's control character settings and processing them in non-canonical mode.Tested with:
getpass'secho_charshould not affect keyboard shortcuts #138577📚 Documentation preview 📚: https://cpython-previews--141597.org.readthedocs.build/